Skip to content

fix(file-explorer): batch dirCache writes on tree refresh to remove O(N²) freeze#6321

Open
wolfiesch wants to merge 3 commits into
stablyai:mainfrom
wolfiesch:wolfiesch/renderer-dir-cache-batch
Open

fix(file-explorer): batch dirCache writes on tree refresh to remove O(N²) freeze#6321
wolfiesch wants to merge 3 commits into
stablyai:mainfrom
wolfiesch:wolfiesch/renderer-dir-cache-batch

Conversation

@wolfiesch

Copy link
Copy Markdown
Contributor

Summary

The File Explorer freezes for several seconds after a filesystem watcher overflow on large worktrees. refreshTree() reloaded every expanded directory via Promise.all(expanded.map(loadDir)), and each loadDir does two full { ...prev } spreads of the entire dirCache (a loading commit and a result commit). With N expanded dirs that is 2N shallow-copies of a growing N-key object — O(N²).

This routes the expanded-dir refresh through refreshFileExplorerExpandedDirs(), which loads all expanded dirs concurrently and commits exactly two batched setDirCache updates (one loading, one result) regardless of N — O(N). Root load and the public hook surface are unchanged.

Screenshots

No visual change — refresh behavior is identical; only the dirCache state-update batching changed.

Testing

  • pnpm typecheck (all three configs, clean)
  • pnpm test — new useFileExplorerTree.test.ts green; the right-sidebar suite shows no new failures (8 pre-existing pr-comment-* failures were confirmed present on a clean main checkout and are unrelated to this change)
  • pnpm build (desktop + native, clean)
  • oxlint clean on changed files
  • Added a unit test asserting the batched two-commit behavior

Evidence

A renderer CPU profile (V8 Profiler via CDP) captured during a real watcher-overflow refresh measured ~7.3 s of contiguous renderer-main-thread block at N≈6000 expanded dirs, entirely inside the two setDirCache spread closures of loadDir. The flattened-rows reprojection (visitChildren) measured ~10 ms (noise) and is not a contributor at this scale.

This freeze is renderer-side; the off-thread stat fanout work (PR #5310 / #5527) does not touch this code path. (A separate standalone watcher benchmark exists as related evidence for moving watcher work off the main thread — it is not the justification for this change.)

AI Review Report

Reviewed with an AI coding agent. Checks run: the change is a pure renderer-side refactor of React state batching with no IPC, path-handling, command-execution, auth, secrets, or dependency surface touched. Cross-platform: no platform-specific code, shortcuts, labels, shell behavior, or Electron platform differences are involved (renderer state only). Verified the public hook return surface (refreshTree / loadDir / setDirCache / refreshDir) is unchanged and both consumers (FileExplorer.tsx, file-explorer-watcher-reconcile.ts) are unaffected. The load-token/session cancellation semantics from the original loadDir are preserved in the batched path.

Security Audit

No new input handling, command execution, path handling, auth, secrets, dependency, or IPC surface. The change only alters how directory-listing results (already fetched by the existing readRuntimeDirectory path) are committed to component-local React state. No follow-up needed.

Notes

  • The fix is O(N) in the number of expanded directories; root load is unchanged.
  • visitChildren (flattened-row reprojection) was profiled as ~10 ms noise at N≈6000 and is intentionally left unchanged; if a much larger tree ever makes it a contributor, diffing the changed subtree instead of re-walking would address it, but there is no current evidence it matters.

…(N²) freeze

refreshTree() reloaded every expanded directory via Promise.all(expanded.map(loadDir)),
and each loadDir did two full { ...prev } spreads of the whole dirCache (a loading
commit and a result commit). With N expanded dirs that is 2N shallow-copies of a
growing N-key object — O(N²). A renderer CPU profile during a watcher-overflow
refresh measured ~7.3s of contiguous main-thread block at N≈6000, entirely in those
two setDirCache spread closures.

Route the expanded-dir refresh through refreshFileExplorerExpandedDirs(), which loads
all dirs concurrently and commits exactly two batched setDirCache updates (one loading,
one result) regardless of N — O(N). Root load and the public hook surface are unchanged.

Add a unit test asserting the batched two-commit behavior.
Copilot AI review requested due to automatic review settings June 25, 2026 02:47

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Refactors the File Explorer tree refresh path to eliminate an O(N²) renderer freeze caused by repeated dirCache object spreads when reloading many expanded directories, by batching dirCache updates during refresh.

Changes:

  • Introduces refreshFileExplorerExpandedDirs() to concurrently reload all expanded directories while committing exactly two setDirCache updates (loading + results).
  • Extracts entry→node conversion into a shared entriesToTreeNodes() helper used by both loadDir() and the batched refresh.
  • Adds a unit test covering the “two cache commits” batching behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
src/renderer/src/components/right-sidebar/useFileExplorerTree.ts Routes expanded-dir refresh through a batched loader to avoid per-directory dirCache spreads; shares node-building logic via entriesToTreeNodes.
src/renderer/src/components/right-sidebar/useFileExplorerTree.test.ts Adds a unit test asserting the expanded-dir refresh does one loading cache commit and one result cache commit.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@coderabbitai

coderabbitai Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: d1a1c686-b572-4695-bc98-43c7f3891b7a

📥 Commits

Reviewing files that changed from the base of the PR and between 41338c3 and df62850.

📒 Files selected for processing (1)
  • src/renderer/src/components/right-sidebar/useFileExplorerTree.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/renderer/src/components/right-sidebar/useFileExplorerTree.ts

📝 Walkthrough

Walkthrough

The file explorer tree refresh flow now uses a shared helper to convert directory entries into tree nodes, refreshes expanded directories concurrently, and preserves loading cache state while reads are in progress. The refresh path now calls the new helper with runtime-backed directory reads, and a test covers the cache commit sequence and directory read counts.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed Clear and specific; it matches batching dirCache writes to fix the file-explorer refresh freeze.
Description check ✅ Passed All required sections are present and filled with concrete details, testing, AI review, security, and notes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
src/renderer/src/components/right-sidebar/useFileExplorerTree.ts (2)

83-136: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a short why-comment for the two batched cache commits.

This helper intentionally waits and commits loading/results in two batches to avoid the prior O(N²) cache-copy path; that constraint is non-obvious from the code alone.

Proposed comment
+  // Why: expanded refresh can touch many directories; keep cache writes batched
+  // so refreshing large worktrees stays O(N) instead of copying per directory.
   setDirCache((prev) => {

As per coding guidelines, “When writing or modifying code driven by a design doc or non-obvious constraint, add a comment explaining why the code behaves the way it does.”

Source: Coding guidelines


260-264: 🚀 Performance & Scalability | 🔵 Trivial

Reduce redundant root re-scan when refreshing the file explorer.

Line 256 explicitly force-loads the root directory (worktreePath). Lines 260–264 then map the full expanded set into expandedDirs, which triggers refreshFileExplorerExpandedDirs to queue a second load for the same root path if it happens to be present in expanded.

Filter worktreePath out before mapping to prevent the duplicated read operation.

Diff context
    const expandedDirs = Array.from(expanded).map((dirPath) => ({
      dirPath,
      depth: splitPathSegments(dirPath.slice(worktreePath.length + 1)).length - 1
    }))

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 66568433-27d0-44c4-ba68-778d6fc2e284

📥 Commits

Reviewing files that changed from the base of the PR and between 0be512f and 41338c3.

📒 Files selected for processing (2)
  • src/renderer/src/components/right-sidebar/useFileExplorerTree.test.ts
  • src/renderer/src/components/right-sidebar/useFileExplorerTree.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants